Skip to content

feat(concept): live auctions in react native exploration - #13499

Draft
brainbicycle wants to merge 38 commits into
mainfrom
brian/live-auction-exploration
Draft

feat(concept): live auctions in react native exploration#13499
brainbicycle wants to merge 38 commits into
mainfrom
brian/live-auction-exploration

Conversation

@brainbicycle

@brainbicycle brainbicycle commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

This PR resolves []

Description

Proof of concept and spike on a 1:1 port to support live auctions in React Native.

Here is a basic lots view with lot history responding to websocket events and emitting bid events:
https://github.com/user-attachments/assets/5e1dd5fc-71c5-453a-ba5b-f19ce4c13803

PR Checklist

  • I have tested my changes on the following platforms:
    • Android.
    • iOS.
  • I hid my changes behind a feature flag, or they don't need one.
  • I have included screenshots or videos at least on Android, or I have not changed the UI.
  • I have added tests, or my changes don't require any.
  • I added an app state migration, or my changes do not require one.
  • I have documented any follow-up work that this PR will require, or it does not require any.
  • I have added a changelog entry below, or my changes do not require one.

To the reviewers 👀

  • I would like at least one of the reviewers to run this PR on the simulator or device.
Changelog updates

Changelog updates

Cross-platform user-facing changes

iOS user-facing changes

Android user-facing changes

Dev changes

Need help with something? Have a look at our docs, or get in touch with us.

brainbicycle and others added 23 commits January 21, 2026 11:14
Implements withSuspense pattern to fetch static auction data including sale info, causality JWT token, and user bidding credentials (paddle number, bidder ID). Includes loading and error states.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Add WebSocket connection management for live auctions with real-time bidding support. Includes connection state management, heartbeat (send "2" every 1s), reconnection logic (500ms retry), and state reducer for handling auction events.

Key features:
- WebSocket connection with auto-reconnection
- Duplicate event prevention with processed event tracking
- Derived lot state calculations (reserve status, asking price, bid counts)
- Pending bid tracking with UUID-based response matching
- Disconnect warning (debounced to 1.1s)
- Pass role: PARTICIPANT to causalityJWT to fetch proper credentials

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Use the correct causalityURL environment variable instead of parsing predictionURL. The predictionURL points to live.artsy.net (webview host) while causalityURL points to causality.artsy.net (websocket host).

Also fixes TypeScript timeout type errors and removes unused imports.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Add comprehensive tests for the INITIAL_STATE_RECEIVED reducer action to verify:
- Single and multiple lot parsing
- Event deduplication
- Event sorting by timestamp
- Derived state calculations
- Operator connected status
- Sale on hold state

All tests passing.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Updates the InitialFullSaleStateMessage to match the actual payload structure
from the websocket server:
- Use fullLotStateById (object) instead of lots (array)
- Rename onHoldMessage to saleOnHoldMessage
- Add FullLotStateData and DerivedLotStateData types

Also exports liveAuctionReducer from the hook for direct testing instead of
duplicating reducer logic in tests.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Adds test coverage for all reducer action types:
- Connection state (CONNECTION_OPENED, CONNECTION_CLOSED, warnings)
- Lot updates (LOT_UPDATE_RECEIVED with new/existing lots, deduplication)
- Current lot changes (CURRENT_LOT_CHANGED)
- Bidding flow (BID_PLACED, BID_RESPONSE_RECEIVED with success/error)
- Sale state (SALE_ON_HOLD_CHANGED, OPERATOR_STATUS_CHANGED)

Test coverage increased from 9 to 26 tests, covering all state transitions
and edge cases based on the Swift implementation patterns.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Add a horizontal pageable carousel for browsing auction lots with:
- PagerView-based carousel with adjacent card peeking
- Spring-based scale/opacity animations (focused: 1.0, unfocused: 0.85)
- Lot cards displaying asking price, bidding status, reserve status, online bid count
- Smart bid button that changes text based on lot state
- Comprehensive unit tests for all components

The carousel replaces the simple lot info display in LiveSale.tsx and provides
a more engaging way to browse lots. Future work includes adding artwork images,
artist names, and estimate ranges when GraphQL data is available.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Add GraphQL query to fetch artwork data for auction lots and display:
- Artwork images with aspect ratio handling
- Artist names
- Lot titles
- Estimate ranges
- Fallback to placeholder when no image available

The GraphQL query matches the iOS implementation (saleArtworksConnection with all: true)
and creates a Map of artwork metadata keyed by lotLabel. The carousel card component
now displays rich artwork information when focused.

All tests updated and passing.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
… IDs

The bug was a mismatch between how we keyed the artwork metadata map and how
the WebSocket identifies lots:

**Problem:**
- GraphQL provides both `internalID` (UUID) and `lotLabel` ("1", "2", "3")
- WebSocket messages use `internalID` (UUID) as the `lotId`
- We were keying the metadata map by `lotLabel`, causing lookups to fail

**Solution:**
- Key the metadata map by `internalID` (UUID) to match WebSocket lot IDs
- Store `lotLabel` as a property within the metadata for display purposes
- Matches the iOS Swift implementation pattern (LiveAuctionLot has both IDs)

**Testing:**
- Added comprehensive test to prevent this regression
- Test documents the expected behavior and validates the mapping logic
- Added debug logging to help diagnose similar issues in the future

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Cleans up LiveSale.tsx to show only sale name and carousel. All debug
information (connection status, credentials, stats, pending bids) moved
to new LiveSaleDebugView modal accessed via header button.

Adds DTShowLiveSaleDebugButton dev toggle to enable debug modal in
production builds (TestFlight/live apps) for troubleshooting live
auction issues.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
The lot order was incorrect because we were iterating through
Object.entries(fullLotStateById), which doesn't preserve order.
Now iterate through artworkMetadata.keys() (which preserves GraphQL
order) and look up lots in fullLotStateById. This matches the Swift
implementation which iterates through saleArtworks array.

Also removed incorrect sorting by UUID extraction in LiveLotCarousel
since lotId is a UUID, not a lot number.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Fixed image scaling issue where images were zoomed in incorrectly.
Now calculates explicit width based on screen dimensions and aspect
ratio, then uses resizeMode="contain" to match Swift's scaleAspectFit
behavior. Container has fixed height for predictable carousel sizing.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
@brainbicycle brainbicycle self-assigned this Apr 20, 2026
@brainbicycle
brainbicycle marked this pull request as draft April 20, 2026 17:57
brainbicycle and others added 5 commits April 21, 2026 12:30
…ot detection

Implements the React Native equivalent of the Swift LiveAuctionBidButton:
- Discriminated union type covering all 13 bidding progress states
- deriveBidButtonState hook derives button state from LiveAuctionState
- Outbid flash animation (bidBecameMaxBidder → biddable shows brief "Outbid")
  with state queuing during animation (two tests skipped pending RNTL fix)
- Registration status derived from me.bidders qualifiedForBidding (matching native)
- LiveLotCarouselCard now delegates to LiveAuctionBidButton via useLiveAuction context

Fixes a discrepancy vs Swift where sold lots showed "Register to Bid" instead of
"Sold": the reducer was ignoring the server's derivedLotState.biddingStatus and
recalculating purely from eventHistory, which may be incomplete for already-sold
lots. Now trusts the server when it reports biddingStatus: "Complete". Also stops
skipping lots absent from fullLotStateById so they appear in state.lots.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Add LiveAuctionEventFeed component and useLiveAuctionEventFeed hook,
  wired into the lot carousel card
- Add deriveLotEventFeed utility that builds display-ready feed events
  from raw lot event history, with full test coverage
- Fix LiveOperatorEventUndone parsing: wire sends the cancelled event ID
  as a nested object `event.eventId`, not a top-level `hostedEventId`
  (mirrors Obj-C Mantle mapping `hostedEventID -> "event.eventId"`)
- Fix sellingToBidder/floorWinningBidder to read from nested wire
  objects, falling back to server values when event history is incomplete

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Move cancelled/pending gray out of the top-level check into the bid
  case only — Swift only grays bid rows when cancelled, non-bid events
  (warning, finalCall, lotOpen, closed) keep their fixed colors
- My winning bid (isMine && isTopBid) now renders gray (mono60) to match
  Swift's artsyGrayMedium, not black
- Replace invalid purple100 token with blue100 for lotOpen rows

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
OfflineBidder bids are placed by the operator and don't go through the
online confirmation flow (CompositeOnlineBidConfirmed), so they were
always resolving isPending=true and rendering gray instead of black.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
brainbicycle and others added 10 commits April 23, 2026 11:14
Mirrors native iOS behaviour in LiveAuctionLotViewController where
bidHistoryViewController.view.isHidden is true whenever the lot's ID
does not match the auction's currentLotId.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The wire sends events as a dict keyed by eventId (not an array under
lotEvents), with lotId embedded inside each event value — matching the
native processLotEventBroadcast which reads json["events"] as a dict.

Normalize to { lotId, lotEvents[] } at the dispatch site so the reducer
is unchanged. Fixes crash when any live lot event is received.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Logs connection open/close, every inbound message with a concise
summary of key fields, and outbound bids — all prefixed [LiveAuction]
for easy filtering in Metro. Gated on __DEV__ so no prod impact.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Currency: sale.currency returns the ISO code ("USD"), not a symbol.
Replace Intl.NumberFormat (unreliable in Hermes) with a static lookup
map of currencies Artsy auctions use.

Asking price: calculateDerivedState only sets askingPriceCents when an
AskingPriceChanged event is present in history. Fall back to
serverDerived.askingPriceCents in both INITIAL_STATE_RECEIVED and
LOT_UPDATE_RECEIVED, matching the pattern already used for
sellingToBidderId.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
store-and-network is intercepted by the 15-min cacheMiddleware in RelayNetworkLayer,
meaning auction state changes (upcoming → live) would not appear until the TTL expires.
network-only sets force:true which bypasses the middleware cache and hits the backend directly.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Two bugs prevented bidding from working:
1. lotId was missing from FirstPriceBidPlaced/SecondPriceBidPlaced event payloads —
   the server had no way to know which lot was being bid on.
2. LiveLotCarouselCard ignored the action param from LiveAuctionBidButton,
   always calling placeBid regardless of button state.

Action routing is now correct: "bid" → placeBid, "registerToBid" → navigate
to /auction-registration/{slug}, "submitMaxBid" → stub pending max bid modal (step 3).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Fetch sale.bidIncrements from GraphQL and thread through context
- computeBidAmounts() mirrors the native minimumNextBidCentsIncrement
  algorithm: find the highest-threshold rule <= current price, step forward
- LiveAuctionMaxBidModal: bottom sheet with scrollable amount picker,
  loading/success/error states driven by pendingBids, auto-dismisses on success
- Wire submitMaxBid action in LiveLotCarousel to open the modal

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…eply

Server requires paddleNumber in the ArtsyBidder object — without it, Causality
returns InvalidMessageReply and the bid silently hangs in pending state forever.

Also typed and handled InvalidMessageReply so future payload errors surface as
a bidFailed state rather than leaving the button stuck in biddingInProgress.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Read LiveAuctionSocketCommunicator.swift directly to spec out the full payload.
Three gaps vs what we were sending:
- SecondPriceBidPlaced uses amountCents (not maxAmountCents) — same key as FirstPrice
- Both events require userId in the bidder object (already fetched via me.internalID)
- Both events include clientMetadata User-Agent

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant